跳到主要内容

Java JVM学习-堆的结构

堆的核心概述

一个 JVM 实例只存在一个堆内存,堆也是 Java内存管理的核心区域。

所有的线程共享 Java 堆,但是不能说堆的所有部分都是共享的,因为还有一个线程私有的缓冲区(Thread Local Allocation Buffer,TLAB)。

《Java虚拟机规范》中对 Java堆的描述是:几乎所有的对象实例以及数组都应当在运行时分配在堆上。(The heap is the run-time data area from which memory for almost class instances and arrays is allocated)但是随着 JIT 编译器的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化发生,所有的对象都分配在堆上也渐渐变得不是那么 “绝对” 了。具体看下面逃逸分析那一节

数组和对象可能永远不会存储在栈上,因为栈帧中保存引用,这个引用指向对象或者数组在堆中的位置。

堆也是GC(Garbage Collection,垃圾收集器)执行垃圾回收的重点区域。

在方法结束后,堆中的对象不会马上被移除,仅仅在垃圾收集的时候才会被移除。

  • 也就是触发了 GC的时候,才会进行回收
  • 如果堆中对象马上被回收,那么用户线程就会收到影响,因为有 stop the word

Java 堆区在 JVM启动的时候(Bootstrap 启动时)即被创建,其空间大小也就确定了。是 JVM管理的最大一块内存空间。《Java虚拟机规范》规定,堆可以处于物理上不连续的内存空间中,但在逻辑上它应该被视为连续的。(就是虚拟内存的知识)

堆内存的大小是可以调节的。

-Xms10m:最小堆内存

-Xmx10m:最大堆内存

可以在 JDK 提供的 JVisualVM 工具看到分配的资源,以及使用情况,它位于 JDK 的 bin 目录下(注意版本)

image.png

第一次打开需要装个 VisualGC 插件,使之能看到 GC 情况

image.png

堆的结构

现代垃圾收集器大部分都基于分代收集理论设计,堆空间细分为:

image.png

Java 7及之前堆内存逻辑上分为三部分:新生区 + 养老区 + 永久区

  • Young Generation Space 新生区 Young/New 又被划分为 Eden区和 Survivor区
  • Tenure Generation space 养老区 Old/Tenure
  • Permanent Space 永久区 Perm

Java 8及之后堆内存逻辑上分为三部分:新生区 + 养老区 + 元空间

  • Young Generation Space新生区 Young/New 又被划分为 Eden区和 Survivor区
  • Tenure Generation space 养老区 Old/Tenure
  • Meta Space 元空间 Meta

约定:下面几个名词代表的意思是一样的

  • 新生区 = 新生代 = 年轻代
  • 养老区 = 老年区 = 老年代
  • 永久区 = 永久代 = 持久代

堆空间大小设置

Java 堆区用于存储 Java对象实例,那么堆的大小在 JVM启动时就已经设定好了,可以通过选项 -Xmx-Xms 来设置堆空间大小(年轻代 + 老年代)。

  • -Xms 用于表示堆区的起始内存,等价于 -xx:InitialHeapSize
  • -Xmx 则用于表示堆区的最大内存,等价于 -XX:MaxHeapSize
# 注意:-X 是 JVM的运行参数  ms 是 memory start
-Xms600m -Xmx600m

一旦堆区中的内存大小超过 -Xmx 所指定的最大内存时,将会抛出 outOfMemoryError 异常。

通常会将 -Xms-Xmx 两个参数配置相同的值,其目的是为了能够在 Java垃圾回收机制清理完堆区后不需要重新分隔计算堆区的大小,从而提高性能。

默认情况下

  • 初始内存大小:物理电脑内存大小 / 64
  • 最大内存大小:物理电脑内存大小 / 4
public class Temp {
public static void main(String[] args) {
// 返回 Java虚拟机中的堆内存总量
long initialMemory = Runtime.getRuntime().totalMemory() / 1024 / 1024;
// 返回 Java虚拟机试图使用的最大堆内存
long maxMemory = Runtime.getRuntime().maxMemory() / 1024 / 1024;

System.out.println("-Xms:" + initialMemory + "M");
System.out.println("-Xmx:" + maxMemory + "M");

System.out.println("系统内存大小为:" + initialMemory * 64.0 / 1024 + "G");
System.out.println("系统内存大小为:" + maxMemory * 4.0 / 1024 + "G");
}
}

在命令行可以使用 jsp 查看当前的 JVM 进程,jstat -gc 进程号 可以查看这个 JVM 进程的内存使用情况

D:\JavaProject\studyALG\out\production\sort\test>jps
18272 Jps
16100
20460 Launcher
3996 RemoteMavenServer36

D:\JavaProject\studyALG\out\production\sort\test>jstat -gc 3996
S0C S1C S0U S1U EC EU OC OU MC MU CCSC CCSU YGC YGCT FGC FGCT GCT
0.0 0.0 0.0 0.0 14336.0 3072.0 16384.0 7279.1 17664.0 16652.6 2304.0 1945.6 3 0.012 3 0.053 0.065

OC:就是老年代总量 OU:就是当前使用的老年代容量 EC:就是伊甸园区总量 EU:伊甸园区使用的容量 S0C:Survivor0 区 S1C:Survivor1 区

所以这里总量就是 OC + EC + S0C(或者S1C)

OutOfMemoryError

参考资料 Java中的常见OOM及原因

public class OOMTest {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
while(true) {
list.add(999999999);
}
}
}

和 StackOverflowError 一样,写个死循环就会把内容用完

有如下几种 OOM 报错

java.lang.OutOfMemoryError:Java heap space

这是最常见的OOM原因。

堆中主要存放各种对象实例,还有常量池等结构。当 JVM发现堆中没有足够的空间分配给新对象时,抛出该异常。具体来讲,在刚发现空间不足时,会先进行一次 Full GC,如果 GC后还是空间不足,再抛出异常。

引起空间不足的原因主要有:

  • 业务高峰,创建对象过多
  • 内存泄露
  • 内存碎片严重,无法分配给大对象

java.lang.OutOfMemoryError:Metaspace

方法区主要存储类的元信息,实现在元数据区。当 JVM发现元数据区没有足够的空间分配给加载的类时,抛出该异常。

引起元数据区空间不足的原因主要有:

  • 加载的类太多,常见于Tomcat等容器中

但是元数据区被实现在堆外,主要受到进程本身的内存限制,这种实现下很难溢出。

java.lang.OutOfMemoryError:Permgen space jdk7 中,方法区被实现在永久代中,错误原因同上。

永久代非常小,而且不会被回收,很容易溢出,因此,jdk8 彻底废除了永久代,将方法区实现在元数据区。

java.lang.OutOfMemoryError:Unable to create new native thread

以 Linux系统为例,JVM创建的线程与操作系统中的线程一一对应,受到以下限制:

  • 进程和操作系统的内存资源限制。其中,一个 JVM线程至少要占用 OS的线程栈 + JVM的虚拟机栈 = 8MB + 1MB = 9MB(当然 JVM实现可以选择不使用这 1MB的 JVM虚拟机栈)。
  • 进程和操作系统的线程数限制。
  • Linux 中的线程被实现为轻量级进程,因此,还受到 pid数量的限制。

当无法在操作系统中继续创建线程时,抛出上述异常。

解决办法从原因中找:

  • 内存资源:调小OS的线程栈、JVM的虚拟机栈。
  • 线程数:增大线程数限制。
  • pid:增大pid范围。

Java中 Error 和 Exception 的区别

Error 类和 Exception 类的父类都是 throwable 类,他们的区别是:

  • Error 类一般是指与虚拟机相关的问题,如系统崩溃,虚拟机错误,内存空间不足,方法调用栈溢等。对于这类错误的导致的应用程序中断,仅靠程序本身无法恢复和预防,遇到这样的错误,建议让程序终止。

  • Exception 类表示程序可以处理的异常,可以捕获且可能恢复。遇到这类异常,应该尽可能处理异常,使程序恢复运行,而不应该随意终止异常。

Exception 类又分为运行时异常(Runtime Exception)和受检查的异常(Checked Exception)

运行时异常:ArithmeticExceptionIllegalArgumentException,编译能通过,但是一运行就终止了,程序不会处理运行时异常,出现这类异常,程序会终止。

而受检查的异常,要么用 try...catch 捕获,要么用 throws 字句声明抛出,交给它的父类处理,否则编译不会通过。

元空间和永久代

Java7 的 JVM 结构图

Java7 及以前版本的细化 JVM 结构图

从图中可以看出,在 7 以及之前堆和方法区连在了一起,但这并不能说堆和方法区是一起的,它们在逻辑上依旧是分开的。但在物理上来说,它们又是连续的一块内存,下面的图可能可以帮助我们更好的理解。

永久代和方法区的关系?

《Java 虚拟机规范》只是规定了有方法区这么个概念和它的作用,并没有规定如何去实现它。那么,在不同的 JVM 上方法区的实现肯定是不同的了。

“永久代(Permanet Generation,也称 PermGen)”。对于习惯了在 HotSpot 虚拟机上开发、部署的程序员来说,很多人都愿意将方法区称作永久代。

方法区和永久代的关系很像 Java 中接口和类的关系,类实现了接口,而永久代就是 HotSpot 虚拟机对虚拟机规范中方法区的一种实现方式。 也就是说,本质上来讲两者并不等价,仅因为 Hotspot 将 GC 分代扩展至方法区,或者说使用永久代来实现方法区。在其它虚拟机上是没有永久代的概念的,永久代是 Hotspot 针对该规范进行的实现。

Java7 及以前版本的 Hotspot 中方法区位于永久代中。同时,永久代和堆是相互隔离的,但它们使用的物理内存是连续的。

注意:永久代的垃圾收集是和老年代捆绑在一起的,因此无论谁满了,都会触发永久代和老年代的垃圾收集。

然后,在 Java8 中,时代变了,Hotspot 取消了永久代。

元空间和方法区的关系?

对于 Java8,HotSpots 取消了永久代,那么是不是就没有方法区了呢?

当然不是,方法区只是一个规范,只不过它的实现变了。

在 Java8 中,元空间(Metaspace)代替了永久代,而方法区存在于元空间(Metaspace)中。同时,元空间不再与堆连续,而且是存在于本地内存(Native memory)。

针对 Java8 的调整,我们再次对内存结构图进行调整。现在 JVM 的运行时数据区变成下面这样:

元空间存在于本地内存,意味着只要本地内存足够,它不会出现像永久代中的 “java.lang.OutOfMemoryError: PermGenspace”

默认情况下元空间是可以无限使用本地内存的,但为了不让它如此膨胀,JVM 同样提供了参数来限制它使用的使用。

-XX:MetaspaceSize,class metadata 的初始空间配额,以 bytes 为单位,达到该值就会触发垃圾收集进行类型卸载,同时 GC 会对该值进行调整:如果释放了大量的空间,就适当的降低该值;如果释放了很少的空间,那么在不超过 MaxMetaspaceSize(如果设置了的话),适当的提高该值。

-XX:MaxMetaspaceSize,可以为class metadata分配的最大空间。默认是没有限制的。

-XX:MinMetaspaceFreeRatio,在GC之后,最小的 Metaspace 剩余空间容量的百分比,减少为 class metadata 分配空间导致的垃圾收集。

-XX:MaxMetaspaceFreeRatio,在GC之后,最大的 Metaspace 剩余空间容量的百分比,减少为 class metadata 释放空间导致的垃圾收集。

为什么要使用元空间代替永久代?

表面上看是为了避免 OOM 异常。因为通常使用 PermSize 和 MaxPermSize 设置永久代的大小就决定了永久代的上限,但是不是总能知道应该设置为多大合适,如果使用默认值很容易遇到 OOM 错误。所以将其丢到本地内存,只要本地内存足够,它不会出现像永久代中的 “java.lang.OutOfMemoryError: PermGenspace”

当元空间溢出时会得到如下错误: java.lang.OutOfMemoryError: MetaSpace

当使用元空间时,可以加载多少类的元数据就不再由 MaxPermSize 控制,而由系统的实际可用空间来控制。

更深层的原因还是要合并 HotSpot 和 JRockit 的代码,使用了元空间取代永久代,不用担心运行性能问题了,在覆盖到的测试中,取代后程序启动和运行速度降低不超过 1%,但是这点性能损失换来了更大的安全保障。

老年代与年轻代的区别

存储在 JVM 中的 Java对象可以被划分为两类:

一类是生命周期较短的瞬时对象,这类对象的创建和消亡都非常迅速(生命周期短的,及时回收即可),另外一类对象的生命周期却非常长,在某些极端的情况下还能够与 JVM 的生命周期保持一致

Java 堆区进一步细分的话,可以划分为年轻代(YoungGen)和老年代(oldGen)

image.png

其中年轻代又可以划分为 Eden 空间、Survivor0 空间和 Survivor1 空间(有时也叫做 from区、to区)

几乎所有的 Java 对象都是在 Eden 区被 new 出来的。绝大部分的 Java 对象的销毁都在新生代进行了。(有些大的对象在 Eden区无法存储时候,将直接进入老年代)

调整老年代与年轻代比例

除了使用 -Xms 命令整体设置堆空间的大小,也可以单独设置它们的内存占比

-XX:NewRatio 可以配置新生代与老年代在堆结构的占比。默认是 1:2

# 默认是 1:2,如下所示
-XX:NewRatio=2
  • 默认 -XX:NewRatio=2,表示新生代占1,老年代占2,新生代占整个堆的1/3
  • 可以修改 -XX:NewRatio=4,表示新生代占1,老年代占4,新生代占整个堆的1/5

在整个项目中,生命周期长的对象偏多,那么就可以通过调整 老年代的大小,来进行调优,不过一般情况是不会调这个参数的

在 HotSpot中,Eden空间和另外两个 Survivor 空间缺省所占的比例是 8:1:1 开发人员可以通过选项 -xx:SurvivorRatio 调整这个空间比例。比如 -xx:SurvivorRatio=8

image.png

对象分配内存

为新对象分配内存是一件非常严谨和复杂的任务,JVM 的设计者们不仅需要考虑内存如何分配、在哪里分配等问题,并且由于内存分配算法与内存回收算法密切相关,所以还需要考虑 GC 执行完内存回收后是否会在内存空间中产生内存碎片。

主要就是看它会被分配到哪块

image.png

其中年轻代又可以划分为 Eden空间、Survivor0空间和 Survivor1空间(有时也叫做 from区、to区)

对象提升方式

1、new 的对象先放伊甸园区。此区有大小限制。

2、当伊甸园的空间填满时,程序又需要创建对象,JVM 的垃圾回收器将对伊甸园区进行垃圾回收(MinorGC),将伊甸园区中的不再被其他对象所引用的对象进行销毁。再加载新的对象放到伊甸园区

3、然后将伊甸园中的剩余对象移动到幸存者0区(Survivor0)。

4、如果再次触发垃圾回收,此时上次幸存下来的放到幸存者0区的,如果没有回收,就会放到幸存者1区。

5、如果再次经历垃圾回收,此时会重新放回幸存者0区,接着再去幸存者1区。

6、啥时候能去养老区呢?可以设置次数。默认是15次。

7、在养老区,相对悠闲。当养老区内存不足时,再次触发GC:Major GC,进行养老区的内存清理

8、若养老区执行了 Major GC 之后,发现依然无法进行对象的保存,就会产生OOM异常。

图解对象提升方式

创建的对象,一般都是存放在 Eden 区的,当 Eden 区满了后,就会触发GC操作,一般被称为 YGC / Minor GC 操作

image.png

当我们进行一次垃圾收集后,红色的将会被回收,而绿色的还会被占用着,存放在 S0(Survivor From)区。同时给每个对象设置了一个年龄计数器,一次回收后就是 1。

同时 Eden区继续存放对象,当 Eden区再次存满的时候,又会触发一个 MinorGC 操作,此时 GC 将会把 Eden和 Survivor From(S0) 中的对象进行一次收集,把存活的对象放到 Survivor To(S1)区(就是整个复制过去,原先那个 Survivor 就空了),同时让年龄 + 1(结束后 S1 变成 Survivor From,S0 变成 Survivor To,如此交替执行)

image.png

继续不断的进行对象生成和垃圾回收,当 Survivor 中的对象的年龄达到15 的时候,将会触发一次 Promotion 晋升的操作,也就是将年轻代中的对象晋升到老年代中

image.png

这个年龄只会在晋升到老年代之前会考虑,到了老年代就不再管这个年龄了,可以通过 -Xx:MaxTenuringThreshold= N 进行设置这个年龄阈值

思考:幸存区区满了后?

特别注意,在 Eden区满了的时候,才会触发 MinorGC,而幸存者区满了后,不会触发 MinorGC操作

如果 Survivor区满了后,将会触发一些特殊的规则,也就是可能直接晋升老年代

总结:

  • 针对 Survivor0,Survivor1区:复制之后有交换,谁空谁是 to
  • 关于垃圾回收:频繁在新生区收集,很少在养老区收集,几乎不在永久区(元空间)收集

对象分配的特殊情况

image.png

JVM 分配参数

  • -Xmx10240m:代表最大堆
  • -Xms10240m:代表最小堆
  • -Xmn5120m:代表新生代
  • -XXSurvivorRatio=3:代表 Eden:Survivor = 3

根据 Generation-Collection 算法(目前大部分 JVM 采用的算法),一般根据对象的生存周期将堆内存分为若干不同的区域,一般情况将新生代分为 Eden,两块 Survivor;

计算Survivor大小, Eden:Survivor = 3,总大小为 5120, 3x+x+x = 5120 x = 1024

新生代大部分要回收,采用Copying算法,快! 老年代 大部分不需要回收,采用Mark-Compact算法

常用的调优工具

  • JDK命令行(jinfo、jmap、javap)
  • Eclipse:Memory Analyzer Tool
  • Jconsole
  • Visual VM(实时监控 推荐~)
  • Jprofiler(推荐~)
  • Java Flight Recorder(实时监控)
  • GCViewer
  • GCEasy

堆空间的参数设置

-XX:+PrintFlagsInitial # 查看所有的参数的默认初始值
-XX:+PrintFlagsFinal # 查看所有的参数的最终值(可能会存在修改,不再是初始值)


jsp # 查看当前运行中的进程
jinfo -flag 参数名称 进程ID # 具体查看某个参数的指令
# 例:jinfo -flag SurvivorRatio 49909

-Xms # 初始堆空间内存 (默认为物理内存的1/64)
-Xmx # 最大堆空间内存 (默认为物理内存的1/4)
-Xmn # 设置新生代的大小。(初始值及最大值)
-XX:NewRatio # 配置新生代与老年代在堆结构的占比
-XX:SurvivorRatio # 设置新生代中 Eden和 S0/S1空间的比例
-XX:MaxTenuringThreshold # 设置新生代垃圾的最大年龄

-XX:+PrintGCDetails # 输出详细的 GC 处理日志

# 打印 GC 简要信息
-XX:+PrintGC
-verbose:gc

-XX:HandlePromotionFailure # 是否设置空间分配担保

关于这个 -XX:HandlePromotionFailure 参数

在发生 Minor GC 之前,虚拟机会检查老年代最大可用的连续空间是否大于新生代所有对象的总空间。

  • 如果大于,则此次 Minor GC 是安全的
  • 如果小于,则虚拟机会查看 -XX:HandlePromotionFailure 设置值是否允许担保失败。

如果 HandlePromotionFailure=true,那么会继续检查老年代最大可用连续空间是否大于历次晋升到老年代的对象的平均大小。

  • 如果大于,则尝试进行一次 Minor GC,但这次 Minor GC 依然是有风险的;
  • 如果小于,则改为进行一次 Full GC。

如果 HandlePromotionFailure=false,则改为进行一次Full GC。在 JDK6 Update24之 后,HandlePromotionFailure 参数不会再影响到虛拟机的空间分配担保策略,观察 OpenJDK中的源码变化,虽然源码中还定义了 HandlePromotionFailure 参数,但是在代码中已经不会再使用它。

JDK6 Update24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行 Minor GC,否则将进行 Full GC。

堆是分配对象存储的唯一选择吗?

随着 JIT编译期的发展与逃逸分析技术逐渐成熟,栈上分配、标量替换优化技术将会导致一些微妙的变化,所有的对象都分配到堆上也渐渐变得不那么“绝对”了。

在 Java虚拟机中,对象是在 Java堆中分配内存的,这是一个普遍的常识。但是,有一种特殊情况,那就是如果经过逃逸分析(Escape Analysis)后发现,一个对象并没有逃逸出方法的话,那么就可能被优化成栈上分配。这样就无需在堆上分配内存,也无须进行垃圾回收了。这也是最常见的堆外存储技术。

此外,前面提到的基于 openJDK深度定制的 TaoBaoVM,其中创新的 GCIH(GC invisible heap)技术实现 off-heap,将生命周期较长的 Java对象从 heap中移至 heap外,并且 GC不能管理 GCIH内部的 Java对象,以此达到降低 GC的回收频率和提升 GC的回收效率的目的。

逃逸分析概述

如何将堆上的对象分配到栈,需要使用逃逸分析手段。这是一种可以有效减少 Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。

通过逃逸分析,Java Hotspot编译器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。

逃逸分析的基本行为就是分析对象动态作用域:

  • 当一个对象在方法中被定义后,对象只在方法内部使用,则认为没有发生逃逸。
  • 当一个对象在方法中被定义后,它被外部方法所引用,则认为发生逃逸。例如作为调用参数传递到其他地方中。

如下代码所示

public void method() {
Object ob = new Object();
// use obj...
ob = null;
}

没有发生逃逸的对象,则可以分配到栈上,随着方法执行的结束,栈空间就被移除。

public static StringBuffer create(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb;
}

上述代码如果想要 StringBuffer sb 不逃出方法,可以这样写:

public static String create(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}

下面几种情况的逃逸分析

public class EscapeAnalysis {

public EscapeAnalysis obj;

/* 方法返回 EscapeAnalysis 对象,发生逃逸 */
public EscapeAnalysis getInstance() {
return obj == null ? new EscapeAnalysis() : obj;
}

/* 为成员属性赋值,发生逃逸 */
public void setObj() {
this.obj = new EscapeAnalysis();
}

/* 对象的作用域仅在当前方法中有效,没有发生逃逸 */
public void useEscapeAnalysis() {
EscapeAnalysis e = new EscapeAnalysis();
}

/* 引用成员变量的值,发生逃逸 */
public void useEscapeAnalysis1() {
EscapeAnalysis e = getInstance();
}
}

结论,开发中能使用局部变量就不要在方法外面定义

逃逸分析:代码优化

使用逃逸分析,编译器可以对代码做如下优化:

一、栈上分配。将堆分配转化为栈分配。如果一个对象在子程序中被分配,要使指向该对象的指针永远不会逃逸,对象可能是栈分配的候选,而不是堆分配。

二、同步省略。如果一个对象被发现只能从一个线程被访问到,那么对于这个对象的操作可以不考虑同步。

三、分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU寄存器中。

JIT 编译器在编译期间根据逃逸分析的结果,发现如果一个对象并没有逃逸出方法的话,就可能被优化成栈上分配。分配完成后,继续在调用栈内执行,最后线程结束,栈空间被回收,局部变量对象也被回收。这样就无须进行垃圾回收了。

栈上分配

常见的栈上分配的场景 分别是给成员变量赋值、方法返回值、实例引用传递。

测试栈上分配性能的示例代码

public class StackAllocation {
public static void main(String[] args) {
long start = System.currentTimeMillis();
for (int i = 0; i < 10000000; i++) {
alloc();
}
System.out.println("花费时间为:" + (System.currentTimeMillis() - start) + "ms");
}

static void alloc() {
Object ob = new Object();
}
}

首先关掉栈上分配,在 VM 加上如下参数来启动

# 注意,参数前面的 - 表示关闭,+ 表示开启
-Xmx100m -Xms100m -XX:-DoEscapeAnalysis -XX:+PrintGCDetails

可以注意到发生了多次 GC

打开栈上分配

-Xmx100m -Xms100m -XX:+DoEscapeAnalysis -XX:+PrintGCDetails

再执行一遍,发现速度快了很多,而且没有 GC

同步省略

线程同步的代价是相当高的,同步的后果是降低并发性和性能。

在动态编译同步块的时候,JIT编译器可以借助逃逸分析来判断同步块所使用的锁对象是否只能够被一个线程访问而没有被发布到其他线程。如果没有,那么 JIT编译器在编译这个同步块的时候就会取消对这部分代码的同步。这样就能大大提高并发性和性能。

这个取消同步的过程就叫同步省略,也叫锁消除。

public void f() {
Object obj = new Object();
synchronized (obj) {
System.out.println(obj);
}
}

代码中对 obj 这个对象进行加锁,但是 obj 对象的生命周期只在 f() 方法中,并不会被其他线程所访问到,所以在 JIT编译阶段就会被优化掉。优化成:

public void f() {
Object obj = new Object();
System.out.println(obj);
}

标量替换

分离对象或标量替换。有的对象可能不需要作为一个连续的内存结构存在也可以被访问到,那么对象的部分(或全部)可以不存储在内存,而是存储在 CPU寄存器中。

标量(Scalar)是指一个无法再分解成更小的数据的数据。Java 中的原始数据类型就是标量。相对的,那些还可以分解的数据叫做聚合量(Aggregate),Java 中的对象就是聚合量,因为他可以分解成其他聚合量和标量。

在 JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过 JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。这个过程就是标量替换。

public static void main(String[] args) {
alloc();
}

static void alloc() {
Point point = new Point(1, 2);
System.out.println(point.x + " " + print.y);
}

static class Point {
public int x;
public int y;
// constructor...
}

以上代码,经过标量替换后,就会变成:

static void alloc() {
int x = 1;
int y = 2;
System.out.println( x + " " + y );
}

可以看到,Point 这个聚合量经过逃逸分析后,发现他并没有逃逸,就被替换成两个聚合量了。那么标量替换有什么好处呢?就是可以大大减少堆内存的占用。因为一旦不需要创建对象了,那么就不再需要分配堆内存了。

使用 -XX:+EliminateAllocations 开启标量替换(默认开启),允许将对象打散分配在栈上

逃逸分析的缺点

关于逃逸分析的论文在1999年就已经发表了,但直到JDK 1.6才 有实现,而且这项技术到如今也并不是十分成熟的。

其根本原因就是无法保证逃逸分析的性能消耗一定能高于他的消耗。虽然经过逃逸分析可以做标量替换、栈上分配、和锁消除。但是逃逸分析自身也是需要进行一系列复杂的分析的,这其实也是一个相对耗时的过程。

一个极端的例子,就是经过逃逸分析之后,发现没有一个对象是不逃逸的。那这个逃逸分析的过程就白白浪费掉了。

虽然这项技术并不十分成熟,但是它也是即时编译器优化技术中一个十分重要的手段。

注意到有一些观点,认为通过逃逸分析,JVM会在栈上分配那些不会逃逸的对象,这在理论上是可行的,但是取决于JVM设计者的选择。Oracle Hotspot JVM中并未这么做,这一点在逃逸分析相关的文档里已经说明,所以可以明确所有的对象实例都是创建在堆上。

Reference

参考资料 一文读懂 - 元空间和永久代